import assert from "node:assert";
import {gte as semverGte, valid as semverValid} from "semver";
import {Zcl} from "zigbee-herdsman";
import * as fz from "../converters/fromZigbee";
import * as tz from "../converters/toZigbee";
import * as constants from "../lib/constants";
import * as exposes from "../lib/exposes";
import {logger} from "../lib/logger";
import * as m from "../lib/modernExtend";
import * as reporting from "../lib/reporting";
import type {DefinitionWithExtend, Fz, KeyValue, KeyValueAny, Tz} from "../lib/types";
import {
    type UbisysClosuresWindowCovering,
    type UbisysDeviceSetup,
    type UbisysDimmerSetup,
    type UbisysGenLevelCtrl,
    ubisysModernExtend,
} from "../lib/ubisys";
import * as utils from "../lib/utils";

const NS = "zhc:ubisys";
const e = exposes.presets;
const ea = exposes.access;

const manufacturerOptions = {
    /*
     * Ubisys doesn't accept a manufacturerCode on some commands
     * This bug has been reported, but it has not been fixed:
     * https://github.com/Koenkk/zigbee-herdsman/issues/52
     */
    ubisys: {manufacturerCode: Zcl.ManufacturerCode.UBISYS_TECHNOLOGIES_GMBH},
    // @ts-expect-error ignore
    ubisysNull: {manufacturerCode: null},
};

const ubisys = {
    fz: {
        dimmer_setup: {
            cluster: "manuSpecificUbisysDimmerSetup",
            type: ["attributeReport", "readResponse"],
            convert: (model, msg, publish, options, meta) => {
                if (msg.data.capabilities !== undefined) {
                    const capabilities = msg.data.capabilities;
                    const forwardPhaseControl = capabilities & 1;
                    const reversePhaseControl = (capabilities & 2) >>> 1;
                    const reactanceDiscriminator = (capabilities & 0x20) >>> 5;
                    const configurableCurve = (capabilities & 0x40) >>> 6;
                    const overloadDetection = (capabilities & 0x80) >>> 7;
                    return {
                        capabilities_forward_phase_control: !!forwardPhaseControl,
                        capabilities_reverse_phase_control: !!reversePhaseControl,
                        capabilities_reactance_discriminator: !!reactanceDiscriminator,
                        capabilities_configurable_curve: !!configurableCurve,
                        capabilities_overload_detection: !!overloadDetection,
                    };
                }
                if (msg.data.status !== undefined) {
                    const status = msg.data.status;
                    const forwardPhaseControl = status & 1;
                    const reversePhaseControl = (status & 2) >>> 1;
                    const overload = (status & 8) >>> 3;
                    const capacitiveLoad = (status & 0x40) >>> 6;
                    const inductiveLoad = (status & 0x80) >>> 7;
                    return {
                        status_forward_phase_control: !!forwardPhaseControl,
                        status_reverse_phase_control: !!reversePhaseControl,
                        status_overload: !!overload,
                        status_capacitive_load: !!capacitiveLoad,
                        status_inductive_load: !!inductiveLoad,
                    };
                }
                if (msg.data.mode !== undefined) {
                    const mode = msg.data.mode;
                    const phaseControl = mode & 3;
                    const phaseControlValues = {0: "automatic", 1: "forward", 2: "reverse"};
                    return {
                        mode_phase_control: utils.getFromLookup(phaseControl, phaseControlValues),
                    };
                }
            },
        } satisfies Fz.Converter<"manuSpecificUbisysDimmerSetup", UbisysDimmerSetup, ["attributeReport", "readResponse"]>,
        // biome-ignore lint/style/useNamingConvention: ignored using `--suppress`
        dimmer_setup_genLevelCtrl: {
            cluster: "genLevelCtrl",
            type: ["attributeReport", "readResponse"],
            convert: (model, msg, publish, options, meta) => {
                if (msg.data.ubisysMinimumOnLevel !== undefined) {
                    return {minimum_on_level: msg.data.ubisysMinimumOnLevel};
                }
            },
        } satisfies Fz.Converter<"genLevelCtrl", UbisysGenLevelCtrl, ["attributeReport", "readResponse"]>,
        configure_device_setup: {
            cluster: "manuSpecificUbisysDeviceSetup",
            type: ["attributeReport", "readResponse"],
            convert: (model, msg, publish, options, meta) => {
                const result = (meta.state.configure_device_setup !== undefined ? meta.state.configure_device_setup : {}) as KeyValue;
                if (msg.data.inputConfigurations != null) {
                    result.input_configurations = msg.data.inputConfigurations;
                }
                if (msg.data.inputActions != null) {
                    result.input_actions = (msg.data.inputActions as unknown[]).map((el) => Object.values(el));
                }
                return {configure_device_setup: result};
            },
        } satisfies Fz.Converter<"manuSpecificUbisysDeviceSetup", UbisysDeviceSetup, ["attributeReport", "readResponse"]>,
    },
    tz: {
        configure_j1: {
            key: ["configure_j1"],
            convertSet: async (entity, key, value: KeyValueAny, meta) => {
                const log = (message: string) => {
                    logger.warning(`ubisys: ${message}`, NS);
                };
                const sleepSeconds = async (s: number) => {
                    return await new Promise((resolve) => setTimeout(resolve, s * 1000));
                };
                const waitUntilStopped = async () => {
                    let operationalStatus = 0;
                    do {
                        await sleepSeconds(2);
                        const response = await entity.read("closuresWindowCovering", ["operationalStatus"]);
                        operationalStatus = response.operationalStatus;
                    } while (operationalStatus !== 0);
                    await sleepSeconds(2);
                };
                const writeAttrFromJson = async (
                    attr: string,
                    jsonAttr?: string,
                    converterFunc?: (v: unknown) => unknown,
                    delaySecondsAfter?: number,
                ) => {
                    // biome-ignore lint/style/noParameterAssign: ignored using `--suppress`
                    if (!jsonAttr) jsonAttr = attr;
                    if (jsonAttr.startsWith("ubisys")) {
                        // biome-ignore lint/style/noParameterAssign: ignored using `--suppress`
                        jsonAttr = jsonAttr.substring(6, 1).toLowerCase + jsonAttr.substring(7);
                    }
                    if (value[jsonAttr] !== undefined) {
                        let attrValue = value[jsonAttr];
                        if (converterFunc) {
                            attrValue = converterFunc(attrValue);
                        }
                        const attributes = {[attr]: attrValue};
                        await entity.write("closuresWindowCovering", attributes, manufacturerOptions.ubisys);
                        if (delaySecondsAfter) {
                            await sleepSeconds(delaySecondsAfter);
                        }
                    }
                };
                const stepsPerSecond = value.steps_per_second || 50;
                const hasCalibrate = value.calibrate != null;
                // cancel any running calibration
                let mode = (await entity.read("closuresWindowCovering", ["windowCoveringMode"])).windowCoveringMode;
                const modeCalibrationBitMask = 0x02;
                if (mode & modeCalibrationBitMask) {
                    await entity.write("closuresWindowCovering", {windowCoveringMode: mode & ~modeCalibrationBitMask});
                    await sleepSeconds(2);
                }
                // delay a bit if reconfiguring basic configuration attributes
                await writeAttrFromJson("windowCoveringType", undefined, undefined, 2);
                await writeAttrFromJson("configStatus", undefined, undefined, 2);
                // @ts-expect-error ignore
                if (await writeAttrFromJson("windowCoveringMode", undefined, undefined, 2)) {
                    mode = value.windowCoveringMode;
                }
                if (hasCalibrate) {
                    log("Cover calibration starting...");
                    // first of all, move to top position to not confuse calibration later
                    log("  Moving cover to top position to get a good starting point...");
                    await entity.command("closuresWindowCovering", "upOpen", {});
                    await waitUntilStopped();
                    log("  Settings some attributes...");
                    // reset attributes
                    await entity.write<"closuresWindowCovering", UbisysClosuresWindowCovering>(
                        "closuresWindowCovering",
                        {
                            installedOpenLimitLiftCm: 0,
                            installedClosedLimitLiftCm: 240,
                            installedOpenLimitTiltDdegree: 0,
                            installedClosedLimitTiltDdegree: 900,
                            ubisysLiftToTiltTransitionSteps: 0xffff,
                            ubisysTotalSteps: 0xffff,
                            ubisysLiftToTiltTransitionSteps2: 0xffff,
                            ubisysTotalSteps2: 0xffff,
                        },
                        manufacturerOptions.ubisys,
                    );
                    // enable calibration mode
                    await sleepSeconds(2);
                    await entity.write("closuresWindowCovering", {windowCoveringMode: mode | modeCalibrationBitMask});
                    await sleepSeconds(2);
                    // move down a bit and back up to detect upper limit
                    log("  Moving cover down a bit...");
                    await entity.command("closuresWindowCovering", "downClose", {});
                    await sleepSeconds(5);
                    await entity.command("closuresWindowCovering", "stop", {});
                    await sleepSeconds(2);
                    log("  Moving up again to detect upper limit...");
                    await entity.command("closuresWindowCovering", "upOpen", {});
                    await waitUntilStopped();
                    log("  Moving down to count steps from open to closed...");
                    await entity.command("closuresWindowCovering", "downClose", {});
                    await waitUntilStopped();
                    log("  Moving up to count steps from closed to open...");
                    await entity.command("closuresWindowCovering", "upOpen", {});
                    await waitUntilStopped();
                }
                // now write any attribute values present in JSON
                await writeAttrFromJson("installedOpenLimitLiftCm");
                await writeAttrFromJson("installedClosedLimitLiftCm");
                await writeAttrFromJson("installedOpenLimitTiltDdegree");
                await writeAttrFromJson("installedClosedLimitTiltDdegree");
                await writeAttrFromJson("ubisysTurnaroundGuardTime");
                await writeAttrFromJson("ubisysLiftToTiltTransitionSteps");
                await writeAttrFromJson("ubisysTotalSteps");
                await writeAttrFromJson("ubisysLiftToTiltTransitionSteps2");
                await writeAttrFromJson("ubisysTotalSteps2");
                await writeAttrFromJson("ubisysAdditionalSteps");
                await writeAttrFromJson("ubisysInactivePowerThreshold");
                await writeAttrFromJson("ubisysStartupSteps");
                // some convenience functions to not have to calculate
                await writeAttrFromJson("ubisysTotalSteps", "open_to_closed_s", (s) => (s as number) * stepsPerSecond);
                await writeAttrFromJson("ubisysTotalSteps2", "closed_to_open_s", (s) => (s as number) * stepsPerSecond);
                await writeAttrFromJson(
                    "ubisysLiftToTiltTransitionSteps",
                    "lift_to_tilt_transition_ms",
                    (s) => ((s as number) * stepsPerSecond) / 1000,
                );
                await writeAttrFromJson(
                    "ubisysLiftToTiltTransitionSteps2",
                    "lift_to_tilt_transition_ms",
                    (s) => ((s as number) * stepsPerSecond) / 1000,
                );
                if (hasCalibrate) {
                    log("  Finalizing calibration...");
                    // disable calibration mode again
                    await sleepSeconds(2);
                    await entity.write("closuresWindowCovering", {windowCoveringMode: mode & ~modeCalibrationBitMask});
                    await sleepSeconds(2);
                    // re-read and dump all relevant attributes
                    log("  Done - will now read back the results.");
                    await ubisys.tz.configure_j1.convertGet(entity, key, meta);
                }
            },
            convertGet: async (entity, key, meta) => {
                const log = (json: unknown) => {
                    logger.warning(`ubisys: Cover configuration read: ${JSON.stringify(json)}`, NS);
                };
                log(
                    await entity.read("closuresWindowCovering", [
                        "windowCoveringType",
                        "physicalClosedLimitLiftCm",
                        "physicalClosedLimitTiltDdegree",
                        "installedOpenLimitLiftCm",
                        "installedClosedLimitLiftCm",
                        "installedOpenLimitTiltDdegree",
                        "installedClosedLimitTiltDdegree",
                    ]),
                );
                log(
                    await entity.read("closuresWindowCovering", [
                        "configStatus",
                        "windowCoveringMode",
                        "currentPositionLiftPercentage",
                        "currentPositionLiftCm",
                        "currentPositionTiltPercentage",
                        "currentPositionTiltDdegree",
                        "operationalStatus",
                    ]),
                );
                log(
                    await entity.read<"closuresWindowCovering", UbisysClosuresWindowCovering>(
                        "closuresWindowCovering",
                        [
                            "ubisysTurnaroundGuardTime",
                            "ubisysLiftToTiltTransitionSteps",
                            "ubisysTotalSteps",
                            "ubisysLiftToTiltTransitionSteps2",
                            "ubisysTotalSteps2",
                            "ubisysAdditionalSteps",
                            "ubisysInactivePowerThreshold",
                            "ubisysStartupSteps",
                        ],
                        manufacturerOptions.ubisys,
                    ),
                );
            },
        } satisfies Tz.Converter,
        dimmer_setup: {
            key: [
                "capabilities_forward_phase_control",
                "capabilities_reverse_phase_control",
                "capabilities_reactance_discriminator",
                "capabilities_configurable_curve",
                "capabilities_overload_detection",
                "status_forward_phase_control",
                "status_reverse_phase_control",
                "status_overload",
                "status_capacitive_load",
                "status_inductive_load",
                "mode_phase_control",
            ],
            convertSet: async (entity, key, value, meta) => {
                if (key === "mode_phase_control") {
                    utils.assertString(value, "mode_phase_control");
                    const phaseControl = value.toLowerCase();
                    const phaseControlValues = {automatic: 0, forward: 1, reverse: 2};
                    utils.validateValue(phaseControl, Object.keys(phaseControlValues));
                    await entity.write<"manuSpecificUbisysDimmerSetup", UbisysDimmerSetup>(
                        "manuSpecificUbisysDimmerSetup",
                        {mode: utils.getFromLookup(phaseControl, phaseControlValues)},
                        manufacturerOptions.ubisysNull,
                    );
                }
                await ubisys.tz.dimmer_setup.convertGet(entity, key, meta);
            },
            convertGet: async (entity, key, meta) => {
                await entity.read<"manuSpecificUbisysDimmerSetup", UbisysDimmerSetup>(
                    "manuSpecificUbisysDimmerSetup",
                    ["capabilities"],
                    manufacturerOptions.ubisysNull,
                );
                await entity.read<"manuSpecificUbisysDimmerSetup", UbisysDimmerSetup>(
                    "manuSpecificUbisysDimmerSetup",
                    ["status"],
                    manufacturerOptions.ubisysNull,
                );
                await entity.read<"manuSpecificUbisysDimmerSetup", UbisysDimmerSetup>(
                    "manuSpecificUbisysDimmerSetup",
                    ["mode"],
                    manufacturerOptions.ubisysNull,
                );
            },
        } satisfies Tz.Converter,
        // biome-ignore lint/style/useNamingConvention: ignored using `--suppress`
        dimmer_setup_genLevelCtrl: {
            key: ["minimum_on_level"],
            convertSet: async (entity, key, value, meta) => {
                if (key === "minimum_on_level") {
                    await entity.write<"genLevelCtrl", UbisysGenLevelCtrl>(
                        "genLevelCtrl",
                        {ubisysMinimumOnLevel: value as number},
                        manufacturerOptions.ubisys,
                    );
                }
                await ubisys.tz.dimmer_setup_genLevelCtrl.convertGet(entity, key, meta);
            },
            convertGet: async (entity, key, meta) => {
                await entity.read<"genLevelCtrl", UbisysGenLevelCtrl>("genLevelCtrl", ["ubisysMinimumOnLevel"], manufacturerOptions.ubisys);
            },
        } satisfies Tz.Converter,
        configure_device_setup: {
            key: ["configure_device_setup"],
            convertSet: async (entity, key, value: KeyValueAny, meta) => {
                const devMgmtEp = meta.device.getEndpoint(232);
                const cluster = Zcl.Utils.getCluster("manuSpecificUbisysDeviceSetup", null, meta.device.customClusters);
                const attributeInputConfigurations = cluster.getAttribute("inputConfigurations");
                const attributeInputActions = cluster.getAttribute("inputActions");
                assert(attributeInputConfigurations && attributeInputActions);

                // ubisys switched to writeStructure a while ago, change log only goes back to 1.9.x
                // and it happened before that but to be safe we will only use writeStrucutre on 1.9.0 and up
                if (
                    !meta.device.softwareBuildID ||
                    !semverValid(meta.device.softwareBuildID, true) ||
                    !semverGte(meta.device.softwareBuildID, "1.9.0", true)
                ) {
                    logger.warning(`ubisys: update firmware of '${meta.options.friendly_name}' before writing configure_device_setup!`, NS);
                    return;
                }

                if (value.input_configurations != null) {
                    // example: [0, 0, 0, 0]
                    await devMgmtEp.writeStructured(
                        "manuSpecificUbisysDeviceSetup",
                        [
                            {
                                attrId: attributeInputConfigurations.ID,
                                selector: {},
                                dataType: Zcl.DataType.ARRAY,
                                elementData: {
                                    elementType: Zcl.DataType.DATA8,
                                    elements: value.input_configurations,
                                },
                            },
                        ],
                        manufacturerOptions.ubisysNull,
                    );
                }

                if (value.input_actions != null) {
                    // example (default for C4): [[0,13,1,6,0,2], [1,13,2,6,0,2], [2,13,3,6,0,2], [3,13,4,6,0,2]]
                    await devMgmtEp.writeStructured(
                        "manuSpecificUbisysDeviceSetup",
                        [
                            {
                                attrId: attributeInputActions.ID,
                                selector: {},
                                dataType: Zcl.DataType.ARRAY,
                                elementData: {
                                    elementType: Zcl.DataType.OCTET_STR,
                                    elements: value.input_actions,
                                },
                            },
                        ],
                        manufacturerOptions.ubisysNull,
                    );
                }

                if (value.input_action_templates != null) {
                    const templateTypes = {
                        // source: "Zigbee Device Physical Input Configurations Integrator’s Guide"
                        // (can be obtained directly from ubisys upon request)
                        toggle: {
                            getInputActions: (input: number, endpoint: number) => [[input, 0x0d, endpoint, 0x06, 0x00, 0x02]],
                        },
                        toggle_switch: {
                            getInputActions: (input: number, endpoint: number) => [
                                [input, 0x0d, endpoint, 0x06, 0x00, 0x02],
                                [input, 0x03, endpoint, 0x06, 0x00, 0x02],
                            ],
                        },
                        on_off_switch: {
                            getInputActions: (input: number, endpoint: number) => [
                                [input, 0x0d, endpoint, 0x06, 0x00, 0x01],
                                [input, 0x03, endpoint, 0x06, 0x00, 0x00],
                            ],
                        },
                        on: {
                            getInputActions: (input: number, endpoint: number) => [[input, 0x0d, endpoint, 0x06, 0x00, 0x01]],
                        },
                        off: {
                            getInputActions: (input: number, endpoint: number) => [[input, 0x0d, endpoint, 0x06, 0x00, 0x00]],
                        },
                        dimmer_single: {
                            getInputActions: (input: number, endpoint: number, template: KeyValue) => {
                                const moveUpCmd = template.no_onoff || template.no_onoff_up ? 0x01 : 0x05;
                                const moveDownCmd = template.no_onoff || template.no_onoff_down ? 0x01 : 0x05;
                                const moveRate = (template.rate as number) || 50;
                                return [
                                    [input, 0x07, endpoint, 0x06, 0x00, 0x02],
                                    [input, 0x86, endpoint, 0x08, 0x00, moveUpCmd, 0x00, moveRate],
                                    [input, 0xc6, endpoint, 0x08, 0x00, moveDownCmd, 0x01, moveRate],
                                    [input, 0x0b, endpoint, 0x08, 0x00, 0x03],
                                ];
                            },
                        },
                        dimmer_double: {
                            doubleInputs: true,
                            getInputActions: (inputs: number[], endpoint: number, template: KeyValue) => {
                                const moveUpCmd = template.no_onoff || template.no_onoff_up ? 0x01 : 0x05;
                                const moveDownCmd = template.no_onoff || template.no_onoff_down ? 0x01 : 0x05;
                                const moveRate = (template.rate as number) || 50;
                                return [
                                    [inputs[0], 0x07, endpoint, 0x06, 0x00, 0x01],
                                    [inputs[0], 0x06, endpoint, 0x08, 0x00, moveUpCmd, 0x00, moveRate],
                                    [inputs[0], 0x0b, endpoint, 0x08, 0x00, 0x03],
                                    [inputs[1], 0x07, endpoint, 0x06, 0x00, 0x00],
                                    [inputs[1], 0x06, endpoint, 0x08, 0x00, moveDownCmd, 0x01, moveRate],
                                    [inputs[1], 0x0b, endpoint, 0x08, 0x00, 0x03],
                                ];
                            },
                        },
                        cover: {
                            cover: true,
                            doubleInputs: true,
                            getInputActions: (inputs: number[], endpoint: number) => [
                                [inputs[0], 0x0d, endpoint, 0x02, 0x01, 0x00],
                                [inputs[0], 0x07, endpoint, 0x02, 0x01, 0x02],
                                [inputs[1], 0x0d, endpoint, 0x02, 0x01, 0x01],
                                [inputs[1], 0x07, endpoint, 0x02, 0x01, 0x02],
                            ],
                        },
                        cover_switch: {
                            cover: true,
                            doubleInputs: true,
                            getInputActions: (inputs: number[], endpoint: number) => [
                                [inputs[0], 0x0d, endpoint, 0x02, 0x01, 0x00],
                                [inputs[0], 0x03, endpoint, 0x02, 0x01, 0x02],
                                [inputs[1], 0x0d, endpoint, 0x02, 0x01, 0x01],
                                [inputs[1], 0x03, endpoint, 0x02, 0x01, 0x02],
                            ],
                        },
                        cover_up: {
                            cover: true,
                            getInputActions: (input: number, endpoint: number) => [[input, 0x0d, endpoint, 0x02, 0x01, 0x00]],
                        },
                        cover_down: {
                            cover: true,
                            getInputActions: (input: number, endpoint: number) => [[input, 0x0d, endpoint, 0x02, 0x01, 0x01]],
                        },
                        scene: {
                            scene: true,
                            getInputActions: (input: number, endpoint: number, groupId: number, sceneId: number) => [
                                [input, 0x07, endpoint, 0x05, 0x00, 0x05, groupId & 0xff, groupId >> 8, sceneId],
                            ],
                            getInputActions2: (input: number, endpoint: number, groupId: number, sceneId: number) => [
                                [input, 0x06, endpoint, 0x05, 0x00, 0x05, groupId & 0xff, groupId >> 8, sceneId],
                            ],
                        },
                        scene_switch: {
                            scene: true,
                            getInputActions: (input: number, endpoint: number, groupId: number, sceneId: number) => [
                                [input, 0x0d, endpoint, 0x05, 0x00, 0x05, groupId & 0xff, groupId >> 8, sceneId],
                            ],
                            getInputActions2: (input: number, endpoint: number, groupId: number, sceneId: number) => [
                                [input, 0x03, endpoint, 0x05, 0x00, 0x05, groupId & 0xff, groupId >> 8, sceneId],
                            ],
                        },
                    };

                    // first input
                    let input = 0;

                    if (Array.isArray(meta.mapped)) {
                        // first client endpoint - depends on actual device
                        throw new Error("Not supported for groups");
                    }

                    let endpoint = {S1: 2, S2: 3, D1: 2, J1: 2, C4: 1}[meta.mapped.model];
                    // default group id
                    let groupId = 0;
                    const templates = Array.isArray(value.input_action_templates) ? value.input_action_templates : [value.input_action_templates];
                    const resultingInputActions: number[][][] = [];

                    for (const template of templates) {
                        const templateType = templateTypes[template.type as keyof typeof templateTypes];

                        if (!templateType) {
                            throw new Error(
                                `input_action_templates: Template type '${template.type}' is not valid ` +
                                    `(valid types: ${Object.keys(templateTypes)})`,
                            );
                        }

                        if (template.input !== undefined) {
                            input = template.input;
                        }

                        if (template.endpoint !== undefined) {
                            endpoint = template.endpoint;
                        }

                        // C4 cover endpoints only start at 5
                        if ("cover" in templateType && meta.mapped.model === "C4" && endpoint < 5) {
                            endpoint += 4;
                        }

                        let inputActions: number[][] = [];

                        if ("doubleInputs" in templateType) {
                            // double inputs
                            const inputArr = template.inputs !== undefined ? template.inputs : [input, input + 1];
                            inputActions = templateType.getInputActions(inputArr, endpoint, template);

                            if (Array.isArray(inputArr)) {
                                input = Math.max(...inputArr);
                            }
                        } else {
                            if ("scene" in templateType) {
                                // scene(s) (always single input)
                                if (template.scene_id === undefined) {
                                    throw new Error(`input_action_templates: Need an attribute 'scene_id' for '${template.type}'`);
                                }

                                if (template.group_id !== undefined) {
                                    groupId = template.group_id;
                                }

                                inputActions = templateType.getInputActions(input, endpoint, groupId, template.scene_id);

                                if (template.scene_id_2 !== undefined) {
                                    if (template.group_id_2 !== undefined) {
                                        groupId = template.group_id_2;
                                    }

                                    inputActions = inputActions.concat(templateType.getInputActions2(input, endpoint, groupId, template.scene_id_2));
                                }
                            } else {
                                // single input, no scene(s)
                                inputActions = templateType.getInputActions(input, endpoint, template);
                            }
                        }

                        resultingInputActions.push(inputActions);

                        logger.warning(`ubisys: Using input ${input} and endpoint ${endpoint} for '${template.type}'.`, NS);
                        input += 1;
                        endpoint += 1;
                    }

                    // XXX: input_action_templates is not validated
                    //      it could potentially write values that don't fit OCTET_STR (e.g. value at x index is > 0xff)

                    // write length of octet str array at index 0
                    await devMgmtEp.writeStructured(
                        "manuSpecificUbisysDeviceSetup",
                        [
                            {
                                attrId: attributeInputActions.ID,
                                selector: {indicatorType: Zcl.StructuredIndicatorType.Whole, indexes: [0]},
                                dataType: Zcl.DataType.UINT16,
                                elementData: resultingInputActions.flat().length,
                            },
                        ],
                        manufacturerOptions.ubisysNull,
                    );

                    let index = 1;

                    // write in chunks to prevent frame overflow
                    // XXX: depends entirely on the values inside the nested array as far as "not overflowing"
                    //      it also is not optimized for "minimum writes", since count is based on nesting
                    for (const inputAction of resultingInputActions) {
                        await devMgmtEp.writeStructured(
                            "manuSpecificUbisysDeviceSetup",
                            inputAction.map((a) => ({
                                attrId: attributeInputActions.ID,
                                selector: {indicatorType: Zcl.StructuredIndicatorType.Whole, indexes: [index++]},
                                dataType: Zcl.DataType.OCTET_STR,
                                elementData: Buffer.from(a),
                            })),
                            manufacturerOptions.ubisysNull,
                        );
                    }
                }
            },
        } satisfies Tz.Converter,
    },
};

export const definitions: DefinitionWithExtend[] = [
    {
        zigbeeModel: ["S1 (5501)"],
        model: "S1",
        vendor: "Ubisys",
        description: "Power switch S1",
        fromZigbee: [ubisys.fz.configure_device_setup],
        toZigbee: [ubisys.tz.configure_device_setup],
        endpoint: (device) => {
            return {l1: 1, s1: 2};
        },
        extend: [
            // NOTE: identify is supported but no visual indicator so omitted here
            m.onOff({powerOnBehavior: true}),
            m.electricityMeter({cluster: "metering", configureReporting: false}),
            m.commandsOnOff({endpointNames: ["2"]}),
            m.commandsLevelCtrl({endpointNames: ["2"]}),
            m.commandsColorCtrl({endpointNames: ["2"]}),
            ubisysModernExtend.pollCurrentSummDelivered(3),
            ubisysModernExtend.addCustomClusterManuSpecificUbisysDeviceSetup(),
        ],
        configure: async (device, coordinatorEndpoint) => {
            const endpoint = device.getEndpoint(3);
            await reporting.bind(endpoint, coordinatorEndpoint, ["seMetering"]);
            await reporting.readMeteringMultiplierDivisor(endpoint);
            await reporting.instantaneousDemand(endpoint);
        },
        onEvent: (event) => {
            /*
             * As per technical doc page 18 section 7.3.4
             * https://www.ubisys.de/wp-content/uploads/ubisys-s1-technical-reference.pdf
             *
             * This cluster uses the binding table for managing command targets.
             * When factory fresh, this cluster is bound to endpoint #1 to
             * enable local control.
             *
             * We use addBinding to 'record' this default binding.
             */
            if (event.type === "deviceInterview") {
                const ep1 = event.data.device.getEndpoint(1);
                const ep2 = event.data.device.getEndpoint(2);
                ep2.addBinding("genOnOff", ep1);
            }
        },
        ota: true,
    },
    {
        zigbeeModel: ["S1-R (5601)"],
        model: "S1-R",
        vendor: "Ubisys",
        description: "Power switch S1-R",
        fromZigbee: [ubisys.fz.configure_device_setup],
        toZigbee: [ubisys.tz.configure_device_setup],
        endpoint: (device) => {
            return {l1: 1, s1: 2, s2: 3};
        },
        extend: [
            m.identify(),
            m.onOff({powerOnBehavior: true}),
            m.electricityMeter({cluster: "metering", configureReporting: false}),
            m.commandsOnOff({endpointNames: ["2", "3"]}),
            m.commandsLevelCtrl({endpointNames: ["2", "3"]}),
            m.commandsColorCtrl({endpointNames: ["2", "3"]}),
            ubisysModernExtend.pollCurrentSummDelivered((device) => (device.hardwareVersion < 16 ? 4 : 1)),
            ubisysModernExtend.addCustomClusterManuSpecificUbisysDeviceSetup(),
        ],
        configure: async (device, coordinatorEndpoint) => {
            // Series 2 has metering on endpoint 1, older devices on endpoint 4
            // hardwareVersion is 16
            const endpoint = device.getEndpoint(device.hardwareVersion < 16 ? 4 : 1);
            await reporting.bind(endpoint, coordinatorEndpoint, ["seMetering"]);
            await reporting.readMeteringMultiplierDivisor(endpoint);
            await reporting.instantaneousDemand(endpoint);
        },
        onEvent: (event) => {
            /*
             * As per technical doc page 18 section 7.3.4
             * https://www.ubisys.de/wp-content/uploads/ubisys-s1-technical-reference.pdf
             *
             * This cluster uses the binding table for managing command targets.
             * When factory fresh, this cluster is bound to endpoint #1 to
             * enable local control.
             *
             * We use addBinding to 'record' this default binding.
             */
            if (event.type === "deviceInterview") {
                const ep1 = event.data.device.getEndpoint(1);
                const ep2 = event.data.device.getEndpoint(2);
                ep2.addBinding("genOnOff", ep1);
            }
        },
        ota: true,
    },
    {
        zigbeeModel: ["S2 (5502)", "S2-R (5602)"],
        model: "S2",
        vendor: "Ubisys",
        description: "Power switch S2",
        exposes: [
            e.switch().withEndpoint("l1"),
            e.switch().withEndpoint("l2"),
            e.action([
                "toggle_s1",
                "toggle_s2",
                "on_s1",
                "on_s2",
                "off_s1",
                "off_s2",
                "recall_*_s1",
                "recal_*_s2",
                "brightness_move_up_s1",
                "brightness_move_up_s2",
                "brightness_move_down_s1",
                "brightness_move_down_s2",
                "brightness_stop_s1",
                "brightness_stop_s2",
            ]),
            e.power_on_behavior().withEndpoint("l1"),
            e.power_on_behavior().withEndpoint("l2"),
            e.power().withAccess(ea.STATE_GET),
            e.energy().withAccess(ea.STATE_GET),
        ],
        fromZigbee: [
            fz.on_off,
            fz.metering,
            fz.command_toggle,
            fz.command_on,
            fz.command_off,
            fz.command_recall,
            fz.command_move,
            fz.command_stop,
            fz.power_on_behavior,
            ubisys.fz.configure_device_setup,
        ],
        toZigbee: [tz.on_off, tz.metering_power, ubisys.tz.configure_device_setup, tz.power_on_behavior, tz.currentsummdelivered],
        endpoint: (device) => {
            return {l1: 1, l2: 2, s1: 3, s2: 4};
        },
        meta: {multiEndpoint: true, multiEndpointSkip: ["power", "energy"]},
        extend: [ubisysModernExtend.addCustomClusterManuSpecificUbisysDeviceSetup(), ubisysModernExtend.pollCurrentSummDelivered(5)],
        configure: async (device, coordinatorEndpoint) => {
            const endpoint = device.getEndpoint(5);
            await reporting.bind(endpoint, coordinatorEndpoint, ["seMetering"]);
            await reporting.readMeteringMultiplierDivisor(endpoint);
            await reporting.instantaneousDemand(endpoint);
        },
        onEvent: (event) => {
            /*
             * As per technical doc page 20 section 7.4.4 and
             *                      page 22 section 7.5.4
             * https://www.ubisys.de/wp-content/uploads/ubisys-s2-technical-reference.pdf
             *
             * This cluster uses the binding table for managing command targets.
             * When factory fresh, this cluster is bound to endpoint #1 to
             * enable local control.
             *
             * This cluster uses the binding table for managing command targets.
             * When factory fresh, this cluster is bound to endpoint #2 to
             * enable local control
             *
             * We use addBinding to 'record' this default binding.
             */
            if (event.type === "deviceInterview") {
                const ep1 = event.data.device.getEndpoint(1);
                const ep2 = event.data.device.getEndpoint(2);
                const ep3 = event.data.device.getEndpoint(3);
                const ep4 = event.data.device.getEndpoint(4);
                ep3.addBinding("genOnOff", ep1);
                ep4.addBinding("genOnOff", ep2);
            }
        },
        ota: true,
    },
    {
        zigbeeModel: ["D1 (5503)", "D1-R (5603)"],
        model: "D1",
        vendor: "Ubisys",
        description: "Universal dimmer D1",
        fromZigbee: [
            fz.on_off,
            fz.brightness,
            fz.metering,
            fz.command_toggle,
            fz.command_on,
            fz.command_off,
            fz.command_recall,
            fz.command_move,
            fz.command_stop,
            fz.lighting_ballast_configuration,
            fz.level_config,
            ubisys.fz.dimmer_setup,
            ubisys.fz.dimmer_setup_genLevelCtrl,
            ubisys.fz.configure_device_setup,
        ],
        toZigbee: [
            tz.light_onoff_brightness,
            tz.ballast_config,
            tz.level_config,
            ubisys.tz.dimmer_setup,
            ubisys.tz.dimmer_setup_genLevelCtrl,
            ubisys.tz.configure_device_setup,
            tz.ignore_transition,
            tz.light_brightness_move,
            tz.light_brightness_step,
            tz.metering_power,
            tz.currentsummdelivered,
        ],
        exposes: [
            e.action([
                "toggle_s1",
                "toggle_s2",
                "on_s1",
                "on_s2",
                "off_s1",
                "off_s2",
                "recall_*_s1",
                "recal_*_s2",
                "brightness_move_up_s1",
                "brightness_move_up_s2",
                "brightness_move_down_s1",
                "brightness_move_down_s2",
                "brightness_stop_s1",
                "brightness_stop_s2",
            ]),
            e.light_brightness(),
            e
                .composite("level_config", "level_config", ea.ALL)
                .withFeature(
                    e
                        .numeric("on_off_transition_time", ea.ALL)
                        .withDescription(
                            "Specifies the amount of time, in units of 0.1 seconds, which will be used during a transition to " +
                                "either the on or off state, when an on/off/toggle command of the on/off cluster is used to turn the light on or off",
                        ),
                )
                .withFeature(
                    e
                        .numeric("on_level", ea.ALL)
                        .withValueMin(1)
                        .withValueMax(254)
                        .withPreset("previous", 255, "Use previous value")
                        .withDescription("Specifies the level that shall be applied, when an on/toggle command causes the light to turn on."),
                )
                .withFeature(
                    e
                        .binary("execute_if_off", ea.ALL, true, false)
                        .withDescription("Defines if you can send a brightness change without to turn on the light"),
                )
                .withFeature(
                    e
                        .numeric("current_level_startup", ea.ALL)
                        .withValueMin(1)
                        .withValueMax(254)
                        .withPreset("previous", 255, "Use previous value")
                        .withDescription("Specifies the initial level to be applied after the device is supplied with power"),
                ),
            e.power().withAccess(ea.STATE_GET),
            e.energy().withAccess(ea.STATE_GET),
            e
                .numeric("ballast_minimum_level", ea.ALL)
                .withValueMin(1)
                .withValueMax(254)
                .withDescription("Specifies the minimum light output of the ballast"),
            e
                .numeric("ballast_maximum_level", ea.ALL)
                .withValueMin(1)
                .withValueMax(254)
                .withDescription("Specifies the maximum light output of the ballast"),
            e
                .numeric("minimum_on_level", ea.ALL)
                .withValueMin(0)
                .withValueMax(255)
                .withDescription(
                    "Specifies the minimum level that shall be applied, when an on/toggle command causes the " +
                        "light to turn on. When this attribute is set to the invalid value (255) this feature is disabled " +
                        "and standard rules apply: The light will either return to the previously active level (before it " +
                        "was turned off) if the OnLevel attribute is set to the invalid value (255/previous); or to the specified " +
                        "value of the OnLevel attribute if this value is in the range 0…254. Otherwise, if the " +
                        "MinimumOnLevel is in the range 0…254, the light will be set to the the previously " +
                        "active level (before it was turned off), or the value specified here, whichever is the larger " +
                        "value. For example, if the previous level was 30 and the MinimumOnLevel was 40 then " +
                        "the light would turn on and move to level 40. Conversely, if the previous level was 50, " +
                        "and the MinimumOnLevel was 40, then the light would turn on and move to level 50.",
                ),
            e.binary("capabilities_forward_phase_control", ea.ALL, true, false).withDescription("The dimmer supports AC forward phase control."),
            e.binary("capabilities_reverse_phase_control", ea.ALL, true, false).withDescription("The dimmer supports AC reverse phase control."),
            e
                .binary("capabilities_reactance_discriminator", ea.ALL, true, false)
                .withDescription("The dimmer is capable of measuring the reactanceto distinguish inductive and capacitive loads."),
            e
                .binary("capabilities_configurable_curve", ea.ALL, true, false)
                .withDescription("The dimmer is capable of replacing the built-in, default dimming curve."),
            e
                .binary("capabilities_overload_detection", ea.ALL, true, false)
                .withDescription("The dimmer is capable of detecting an output overload and shutting the output off."),
            e
                .binary("status_forward_phase_control", ea.ALL, true, false)
                .withDescription("The dimmer is currently operating in AC forward phase control mode."),
            e
                .binary("status_reverse_phase_control", ea.ALL, true, false)
                .withDescription("The dimmer is currently operating in AC reverse phase control mode."),
            e
                .binary("status_overload", ea.ALL, true, false)
                .withDescription("The output is currently turned off, because the dimmer has detected an overload."),
            e
                .binary("status_capacitive_load", ea.ALL, true, false)
                .withDescription("The dimmer's reactance discriminator had detected a capacitive load."),
            e
                .binary("status_inductive_load", ea.ALL, true, false)
                .withDescription("The dimmer's reactance discriminator had detected an inductive load."),
            e.enum("mode_phase_control", ea.ALL, ["automatic", "forward", "reverse"]).withDescription("Configures the dimming technique."),
        ],
        extend: [
            ubisysModernExtend.addCustomClusterManuSpecificUbisysDeviceSetup(),
            ubisysModernExtend.addCustomClusterManuSpecificUbisysDimmerSetup(),
            ubisysModernExtend.addCustomClusterGenLevelCtrl(),
            ubisysModernExtend.pollCurrentSummDelivered(4),
        ],
        configure: async (device, coordinatorEndpoint) => {
            const endpoint = device.getEndpoint(4);
            await reporting.bind(endpoint, coordinatorEndpoint, ["seMetering"]);
            await reporting.readMeteringMultiplierDivisor(endpoint);
            await reporting.instantaneousDemand(endpoint);
        },
        meta: {multiEndpoint: true, multiEndpointSkip: ["state", "brightness", "power", "energy"]},
        endpoint: (device) => {
            return {default: 1, s1: 2, s2: 3};
        },
        onEvent: (event) => {
            /*
             * As per technical doc page 23 section 7.3.4, 7.3.5
             * https://www.ubisys.de/wp-content/uploads/ubisys-d1-technical-reference.pdf
             *
             * We use addBinding to 'record' this default binding.
             */
            if (event.type === "deviceInterview") {
                const ep1 = event.data.device.getEndpoint(1);
                const ep2 = event.data.device.getEndpoint(2);
                ep2.addBinding("genOnOff", ep1);
                ep2.addBinding("genLevelCtrl", ep1);
            }
        },
        ota: true,
    },
    {
        zigbeeModel: ["J1 (5502)", "J1-R (5602)"],
        model: "J1",
        vendor: "Ubisys",
        description: "Shutter control J1",
        fromZigbee: [fz.cover_position_tilt, fz.metering, ubisys.fz.configure_device_setup],
        toZigbee: [
            tz.cover_state,
            tz.cover_position_tilt,
            tz.metering_power,
            ubisys.tz.configure_j1,
            ubisys.tz.configure_device_setup,
            tz.currentsummdelivered,
        ],
        exposes: (device, options) => {
            const coverExpose = e.cover();
            const coverType = !utils.isDummyDevice(device)
                ? device.getEndpoint(1).getClusterAttributeValue("closuresWindowCovering", "windowCoveringType")
                : undefined;
            switch (
                coverType // cf. Ubisys J1 Technical Reference Manual, chapter 7.2.5.1 Calibration
            ) {
                case 0: // Roller Shade, Lift only
                case 1: // Roller Shade two motors, Lift only
                case 2: // Roller Shade exterior, Lift only
                case 3: // Roller Shade two motors exterior, Lift only
                case 4: // Drapery, Lift only
                case 5: // Awning, Lift only
                case 9: // Projector Screen, Lift only
                    coverExpose.withPosition();
                    break;
                case 6: // Shutter, Tilt only
                case 7: // Tilt Blind, Tilt only
                    coverExpose.withTilt();
                    break;
                default:
                    coverExpose.withPosition().withTilt();
                    break;
            }
            return [coverExpose, e.power().withAccess(ea.STATE_GET), e.energy().withAccess(ea.STATE_GET)];
        },
        extend: [
            ubisysModernExtend.addCustomClusterManuSpecificUbisysDeviceSetup(),
            ubisysModernExtend.addCustomClusterClosuresWindowCovering(),
            ubisysModernExtend.pollCurrentSummDelivered(3),
        ],
        configure: async (device, coordinatorEndpoint) => {
            const endpoint1 = device.getEndpoint(1);
            const endpoint3 = device.getEndpoint(3);
            await reporting.bind(endpoint3, coordinatorEndpoint, ["seMetering"]);
            await reporting.readMeteringMultiplierDivisor(endpoint3);
            await reporting.instantaneousDemand(endpoint3);
            await reporting.bind(endpoint1, coordinatorEndpoint, ["closuresWindowCovering"]);
            await reporting.currentPositionLiftPercentage(endpoint1);
        },
        onEvent: (event) => {
            /*
             * As per technical doc page 21 section 7.3.4
             * https://www.ubisys.de/wp-content/uploads/ubisys-j1-technical-reference.pdf
             *
             * We use addBinding to 'record' this default binding.
             */
            if (event.type === "deviceInterview") {
                const ep1 = event.data.device.getEndpoint(1);
                const ep2 = event.data.device.getEndpoint(2);
                ep2.addBinding("closuresWindowCovering", ep1);
            }
        },
        ota: true,
    },
    {
        zigbeeModel: ["C4 (5504)"],
        model: "C4",
        vendor: "Ubisys",
        description: "Control unit C4",
        fromZigbee: [
            fz.command_toggle,
            fz.command_on,
            fz.command_off,
            fz.command_recall,
            fz.command_move,
            fz.command_stop,
            fz.command_cover_open,
            fz.command_cover_close,
            fz.command_cover_stop,
            ubisys.fz.configure_device_setup,
        ],
        toZigbee: [ubisys.tz.configure_device_setup],
        exposes: [
            e.action([
                "toggle_s1",
                "toggle_s2",
                "toggle_s3",
                "toggle_s4",
                "on_s1",
                "on_s2",
                "on_s3",
                "on_s4",
                "off_s1",
                "off_s2",
                "off_s3",
                "off_s4",
                "recall_*_s1",
                "recal_*_s2",
                "recall_*_s3",
                "recal_*_s4",
                "brightness_move_up_s1",
                "brightness_move_up_s2",
                "brightness_move_up_s3",
                "brightness_move_up_s4",
                "brightness_move_down_s1",
                "brightness_move_down_s2",
                "brightness_move_down_s3",
                "brightness_move_down_s4",
                "brightness_stop_s1",
                "brightness_stop_s2",
                "brightness_stop_s3",
                "brightness_stop_s4",
                "cover_open_s5",
                "cover_close_s5",
                "cover_stop_s5",
                "cover_open_s6",
                "cover_close_s6",
                "cover_stop_s6",
            ]),
        ],
        extend: [ubisysModernExtend.addCustomClusterManuSpecificUbisysDeviceSetup()],
        configure: async (device, coordinatorEndpoint) => {
            for (const ep of [1, 2, 3, 4]) {
                await reporting.bind(device.getEndpoint(ep), coordinatorEndpoint, ["genScenes", "genOnOff", "genLevelCtrl"]);
            }
            for (const ep of [5, 6]) {
                await reporting.bind(device.getEndpoint(ep), coordinatorEndpoint, ["genScenes", "closuresWindowCovering"]);
            }
        },
        meta: {multiEndpoint: true},
        endpoint: (device) => {
            return {s1: 1, s2: 2, s3: 3, s4: 4, s5: 5, s6: 6};
        },
        ota: true,
    },
    {
        zigbeeModel: ["H1"],
        model: "H1",
        vendor: "Ubisys",
        description: "Heating regulator",
        meta: {thermostat: {dontMapPIHeatingDemand: true}},
        fromZigbee: [fz.thermostat, fz.thermostat_weekly_schedule],
        toZigbee: [
            tz.thermostat_occupied_heating_setpoint,
            tz.thermostat_unoccupied_heating_setpoint,
            tz.thermostat_local_temperature,
            tz.thermostat_system_mode,
            tz.thermostat_weekly_schedule,
            tz.thermostat_clear_weekly_schedule,
            tz.thermostat_running_mode,
            tz.thermostat_pi_heating_demand,
        ],
        exposes: [
            e
                .climate()
                .withSystemMode(["off", "heat"], ea.ALL)
                .withRunningMode(["off", "heat"])
                .withSetpoint("occupied_heating_setpoint", 7, 30, 0.5)
                .withSetpoint("unoccupied_heating_setpoint", 7, 30, 0.5)
                .withLocalTemperature()
                .withPiHeatingDemand(ea.STATE_GET)
                .withWeeklySchedule(["heat"]),
        ],
        extend: [
            ubisysModernExtend.addCustomClusterHvacThermostat(),
            ubisysModernExtend.openWindowState(),
            ubisysModernExtend.vacationMode(),
            ubisysModernExtend.localTemperatureOffset(),
            ubisysModernExtend.occupiedHeatingSetpointDefault(),
            ubisysModernExtend.remoteTemperatureDuration(),
            ubisysModernExtend.openWindowDetect(),
            ubisysModernExtend.openWindowTimeout(),
            ubisysModernExtend.openWindowDetectionPeriod(),
            ubisysModernExtend.openWindowSensitivity(),
            m.battery({
                percentage: true,
                voltage: true,
                voltageReporting: true,
                percentageReporting: true,
            }),
        ],
        configure: async (device, coordinatorEndpoint) => {
            const endpoint = device.getEndpoint(1);
            const binds = ["genBasic", "genPowerCfg", "genTime", "hvacThermostat"];
            await reporting.bind(endpoint, coordinatorEndpoint, binds);

            // reporting
            // NOTE: temperature is 0.5 deg steps
            // NOTE: unoccupied_heating_setpoint cannot be set via the device itself
            //       so we do not need to setup reporting for this, as reporting slots
            //       seem to be limited.
            await reporting.thermostatSystemMode(endpoint);
            await reporting.thermostatRunningMode(endpoint);
            await reporting.thermostatTemperature(endpoint, {min: 0, max: constants.repInterval.HOUR, change: 50});
            await reporting.thermostatOccupiedHeatingSetpoint(endpoint, {min: 0, max: constants.repInterval.HOUR, change: 50});
            await reporting.thermostatPIHeatingDemand(endpoint, {min: 15, max: constants.repInterval.HOUR, change: 1});

            // read attributes
            // NOTE: configuring reporting on hvacThermostat seems to trigger an immediate
            //       report, so the values are available after configure has run.
            //       this does not seem to be the case for genPowerCfg, so we read
            //       the battery percentage
            await endpoint.read("genPowerCfg", ["batteryPercentageRemaining"]);

            // write attributes
            // NOTE: device checks in every 1h once the device has entered deepsleep
            //       this might be a bit long if you want to set the temperature remotely
            //       update this to every 15 minutes. (value is in 1/4th of a second)
            await endpoint.write("genPollCtrl", {checkinInterval: 4 * 60 * 15});
        },
        ota: true,
    },
    {
        zigbeeModel: ["H10"],
        model: "H10",
        vendor: "Ubisys",
        description: "Heatingcontrol 10-Way",
        meta: {thermostat: {dontMapPIHeatingDemand: true}, multiEndpoint: true},
        fromZigbee: [fz.on_off, fz.thermostat, fz.thermostat_weekly_schedule],
        toZigbee: [
            tz.on_off,
            tz.thermostat_occupied_heating_setpoint,
            tz.thermostat_unoccupied_heating_setpoint,
            tz.thermostat_occupied_cooling_setpoint,
            tz.thermostat_unoccupied_cooling_setpoint,
            tz.thermostat_local_temperature,
            tz.thermostat_system_mode,
            tz.thermostat_weekly_schedule,
            tz.thermostat_clear_weekly_schedule,
            tz.thermostat_running_mode,
            tz.thermostat_pi_heating_demand,
        ],
        endpoint: (device) => {
            return {
                th1: 1,
                th2: 2,
                th3: 3,
                th4: 4,
                th5: 5,
                th6: 6,
                th7: 7,
                th8: 8,
                th9: 9,
                th10: 10,
                l1: 11,
                l2: 12,
                l3: 13,
                l4: 14,
                l5: 15,
                l6: 16,
                l7: 17,
                l8: 18,
                l9: 19,
                l10: 20,
                default: 21,
            };
        },
        exposes: [
            e.switch().withEndpoint("l1"),
            e.switch().withEndpoint("l2"),
            e.switch().withEndpoint("l3"),
            e.switch().withEndpoint("l4"),
            e.switch().withEndpoint("l5"),
            e.switch().withEndpoint("l6"),
            e.switch().withEndpoint("l7"),
            e.switch().withEndpoint("l8"),
            e.switch().withEndpoint("l9"),
            e.switch().withEndpoint("l10"),
            e
                .climate()
                .withEndpoint("th1")
                .withSystemMode(["off", "heat", "cool"], ea.ALL)
                .withRunningMode(["off", "heat", "cool"])
                .withSetpoint("occupied_heating_setpoint", 7, 30, 0.5)
                .withLocalTemperature()
                .withPiHeatingDemand(ea.STATE_GET),
            e
                .climate()
                .withEndpoint("th2")
                .withSystemMode(["off", "heat", "cool"], ea.ALL)
                .withRunningMode(["off", "heat", "cool"])
                .withSetpoint("occupied_heating_setpoint", 7, 30, 0.5)
                .withLocalTemperature()
                .withPiHeatingDemand(ea.STATE_GET),
            e
                .climate()
                .withEndpoint("th3")
                .withSystemMode(["off", "heat", "cool"], ea.ALL)
                .withRunningMode(["off", "heat", "cool"])
                .withSetpoint("occupied_heating_setpoint", 7, 30, 0.5)
                .withLocalTemperature()
                .withPiHeatingDemand(ea.STATE_GET),
            e
                .climate()
                .withEndpoint("th4")
                .withSystemMode(["off", "heat", "cool"], ea.ALL)
                .withRunningMode(["off", "heat", "cool"])
                .withSetpoint("occupied_heating_setpoint", 7, 30, 0.5)
                .withLocalTemperature()
                .withPiHeatingDemand(ea.STATE_GET),
            e
                .climate()
                .withEndpoint("th5")
                .withSystemMode(["off", "heat", "cool"], ea.ALL)
                .withRunningMode(["off", "heat", "cool"])
                .withSetpoint("occupied_heating_setpoint", 7, 30, 0.5)
                .withLocalTemperature()
                .withPiHeatingDemand(ea.STATE_GET),
            e
                .climate()
                .withEndpoint("th6")
                .withSystemMode(["off", "heat", "cool"], ea.ALL)
                .withRunningMode(["off", "heat", "cool"])
                .withSetpoint("occupied_heating_setpoint", 7, 30, 0.5)
                .withLocalTemperature()
                .withPiHeatingDemand(ea.STATE_GET),
            e
                .climate()
                .withEndpoint("th7")
                .withSystemMode(["off", "heat", "cool"], ea.ALL)
                .withRunningMode(["off", "heat", "cool"])
                .withSetpoint("occupied_heating_setpoint", 7, 30, 0.5)
                .withLocalTemperature()
                .withPiHeatingDemand(ea.STATE_GET),
            e
                .climate()
                .withEndpoint("th8")
                .withSystemMode(["off", "heat", "cool"], ea.ALL)
                .withRunningMode(["off", "heat", "cool"])
                .withSetpoint("occupied_heating_setpoint", 7, 30, 0.5)
                .withLocalTemperature()
                .withPiHeatingDemand(ea.STATE_GET),
            e
                .climate()
                .withEndpoint("th9")
                .withSystemMode(["off", "heat", "cool"], ea.ALL)
                .withRunningMode(["off", "heat", "cool"])
                .withSetpoint("occupied_heating_setpoint", 7, 30, 0.5)
                .withLocalTemperature()
                .withPiHeatingDemand(ea.STATE_GET),
            e
                .climate()
                .withEndpoint("th10")
                .withSystemMode(["off", "heat", "cool"], ea.ALL)
                .withRunningMode(["off", "heat", "cool"])
                .withSetpoint("occupied_heating_setpoint", 7, 30, 0.5)
                .withLocalTemperature()
                .withPiHeatingDemand(ea.STATE_GET),
        ],
        extend: [ubisysModernExtend.addCustomClusterHvacThermostat(), ubisysModernExtend.addCustomClusterGenLevelCtrl()],
        configure: async (device, coordinatorEndpoint) => {
            // setup ep 1-10 as thermostats
            // NOTE: doesn't look like fz/tz.thermostat and friends support
            //        multiEndpoint
            const thermostatBinds = ["hvacThermostat"];
            for (let ep = 1; ep <= 10; ep++) {
                const endpoint = device.getEndpoint(ep);
                await reporting.bind(endpoint, coordinatorEndpoint, thermostatBinds);
                await reporting.thermostatSystemMode(endpoint);
                await reporting.thermostatTemperature(endpoint, {min: 0, max: constants.repInterval.HOUR, change: 50});
                await reporting.thermostatOccupiedHeatingSetpoint(endpoint, {min: 0, max: constants.repInterval.HOUR, change: 50});
                await reporting.thermostatOccupiedCoolingSetpoint(endpoint, {min: 0, max: constants.repInterval.HOUR, change: 50});
                await reporting.thermostatPIHeatingDemand(endpoint, {min: 15, max: constants.repInterval.HOUR, change: 1});
                await reporting.thermostatPICoolingDemand(endpoint, {min: 15, max: constants.repInterval.HOUR, change: 1});
            }

            // setup ep 11-20 as on/off switches
            const heaterCoolerBinds = ["genOnOff"];
            for (let ep = 11; ep <= 20; ep++) {
                const endpoint = device.getEndpoint(ep);
                await reporting.bind(endpoint, coordinatorEndpoint, heaterCoolerBinds);
                await reporting.onOff(endpoint);
            }
        },
        ota: true,
    },
    {
        zigbeeModel: ["R0 (5501)"],
        model: "R0",
        vendor: "Ubisys",
        description: "Zigbee Router",
        fromZigbee: [fz.linkquality_from_basic],
        toZigbee: [],
        exposes: [],
        ota: true,
    },
];
